From fa74b609e94ff25a10f63c596414b4e410576a9f Mon Sep 17 00:00:00 2001 From: mdevolde Date: Fri, 12 Dec 2025 16:35:15 +0100 Subject: [PATCH] luci-app-wol: Enables persistent configuration of hosts to wake up Signed-off-by: Martin Devolder --- .../htdocs/luci-static/resources/view/wol.js | 319 ++++++++++++------ .../luci-app-wol/root/etc/config/luci-wol | 1 + .../usr/share/rpcd/acl.d/luci-app-wol.json | 8 +- .../root/usr/share/rpcd/ucode/luci.wol | 2 +- 4 files changed, 228 insertions(+), 102 deletions(-) create mode 100644 applications/luci-app-wol/root/etc/config/luci-wol diff --git a/applications/luci-app-wol/htdocs/luci-static/resources/view/wol.js b/applications/luci-app-wol/htdocs/luci-static/resources/view/wol.js index f67e9d1ccf..0a315d3c3f 100644 --- a/applications/luci-app-wol/htdocs/luci-static/resources/view/wol.js +++ b/applications/luci-app-wol/htdocs/luci-static/resources/view/wol.js @@ -1,8 +1,6 @@ 'use strict'; 'require view'; -'require dom'; 'require uci'; -'require fs'; 'require ui'; 'require rpc'; 'require form'; @@ -11,158 +9,283 @@ const ETHERWAKE_BIN = '/usr/bin/etherwake'; const WAKEONLAN_BIN = '/usr/bin/wakeonlan'; +const PACKAGES_URL = 'admin/system/package-manager'; + return view.extend({ - formdata: { wol: {} }, + outputText: '', callStat: rpc.declare({ object: 'luci.wol', method: 'stat', - params: [ ], - expect: { } + params: [], + expect: {} }), callExec: rpc.declare({ object: 'luci.wol', method: 'exec', - params: [ 'name', 'args' ], - expect: { } + params: ['name', 'args'], + expect: {} }), callHostHints: rpc.declare({ object: 'luci-rpc', method: 'getHostHints', - expect: { '': {} } + expect: { + '': {} + } }), - load: function() { + option_install_etherwake() { + window.open(L.url(PACKAGES_URL) + + '?query=etherwake', '_blank', 'noopener'); + }, + + option_install_wakeonlan() { + window.open(L.url(PACKAGES_URL) + + '?query=wakeonlan', '_blank', 'noopener'); + }, + + load() { return Promise.all([ L.resolveDefault(this.callStat()), this.callHostHints(), - uci.load('etherwake') + uci.load('luci-wol') ]); }, render([stat, hosts]) { - var has_ewk = stat && stat.etherwake, - has_wol = stat && stat.wakeonlan, - m, s, o; + const has_ewk = stat && stat.etherwake, + has_wol = stat && stat.wakeonlan; + let m, s, o; - this.formdata.has_ewk = has_ewk; - this.formdata.has_wol = has_wol; + // Check if at least one Wake on LAN utility is available, else show install buttons + if (!has_ewk && !has_wol) { + m = new form.Map('luci-wol', _('Wake on LAN'), + _('Wake on LAN is a mechanism to boot computers remotely in the local network.')); - m = new form.JSONMap(this.formdata, _('Wake on LAN'), + s = m.section(form.NamedSection, 'packages', 'packages', + _('Required Packages'), + _('At least one Wake on LAN utility is needed. Please install one of the following packages (some extra permissions may be required):')); + + s.render = L.bind(function(view) { + return form.NamedSection.prototype.render.apply(this, arguments) + .then(L.bind(function(node) { + node.appendChild(E('div', { + 'class': 'control-group' + }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': ui.createHandlerFn(view, 'option_install_etherwake', this.map), + 'title': _('Install etherwake package') + }, [_('Install etherwake')]), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': ui.createHandlerFn(view, 'option_install_wakeonlan', this.map), + 'title': _('Install wakeonlan package') + }, [_('Install wakeonlan')]) + ])); + return node; + }, this)); + }, s, this); + + return m.render(); + } + + m = new form.Map('luci-wol', _('Wake on LAN'), _('Wake on LAN is a mechanism to boot computers remotely in the local network.')); - s = m.section(form.NamedSection, 'wol'); + // Default settings section (used executable) + s = m.section(form.NamedSection, 'defaults', 'wol', _('Default Settings')); if (has_ewk && has_wol) { - o = s.option(form.ListValue, 'executable', _('WoL program'), - _('Sometimes only one of the two tools works. If one fails, try the other one')); - + o = s.option(form.ListValue, 'executable', _('Default WoL program'), + _('Choose the default Wake on LAN utility')); o.value(ETHERWAKE_BIN, 'Etherwake'); o.value(WAKEONLAN_BIN, 'Wakeonlan'); - } - - if (has_ewk) { - o = s.option(widgets.DeviceSelect, 'iface', _('Network interface to use'), - _('Specifies the interface the WoL packet is sent on')); - - o.default = uci.get('etherwake', 'setup', 'interface'); - o.rmempty = false; - o.noaliases = true; - o.noinactive = true; + o.default = ETHERWAKE_BIN; + o.onchange = function(ev, section_id, value) { + return m.save(null, true); + }; + } else { + // If only one binary is available, show info message with install button for the other + o = s.option(form.DummyValue, '_info'); + o.rawhtml = true; + o.default = E('div', {}, [ + E('p', {}, [ + _('Binary used') + ': ', + E('strong', {}, has_ewk ? 'Etherwake' : 'Wakeonlan') + ]), + E('p', { + 'style': 'margin-top: 10px' + }, + _('You can also install the alternative Wake on LAN utility (some extra permissions may be required):')), + E('div', { + 'class': 'control-group' + }, [ + E('button', { + 'class': 'btn cbi-button-action', + 'click': ui.createHandlerFn(this, has_ewk ? 'option_install_wakeonlan' : 'option_install_etherwake'), + 'title': _('Install the alternative Wake on LAN package') + }, [_('Install %s').format(has_ewk ? 'wakeonlan' : 'etherwake')]) + ]) + ]); - uci.sections('etherwake', 'target', function(section) { - if (section.mac && section.name) { - // Create a host entry if it doesn't exist - if (!hosts[section.mac]) { - hosts[section.mac] = { name: section.name }; - } - } - }); - - if (has_wol) - o.depends('executable', ETHERWAKE_BIN); } - o = s.option(form.Value, 'mac', _('Host to wake up'), - _('Choose the host to wake up or enter a custom MAC address to use')); + // Targets section with GridSection + s = m.section(form.GridSection, 'target', _('Wake on LAN Targets'), _('Configure hosts that can be woken up. Click the Wake button to send a magic packet.') + '
' + _('Note: wakeonlan binary does not support interface, broadcast, and password options (etherwake only).') + ' ' + _('These options will be ignored if wakeonlan is used.')); + s.addremove = true; + s.anonymous = true; + s.sortable = true; + s.nodescriptions = true; + + // Name column + o = s.option(form.Value, 'name', _('Name'), _('Mandatory')); o.rmempty = false; + o.datatype = 'string'; - L.sortedKeys(hosts).forEach(function(mac) { - o.value(mac, E([], [ mac, ' (', E('strong', [ - hosts[mac].name || + // MAC address column + o = s.option(form.Value, 'mac', _('MAC Address'), _('Mandatory')); + o.rmempty = false; + o.datatype = 'macaddr'; + L.sortedKeys(hosts).forEach(function(mac) { // Add host hints, need 'getHostHints' acl (luci-rpc) + const hint = hosts[mac].name || L.toArray(hosts[mac].ipaddrs || hosts[mac].ipv4)[0] || - L.toArray(hosts[mac].ip6addrs || hosts[mac].ipv6)[0] || - '?' - ]), ')' ])); + L.toArray(hosts[mac].ip6addrs || hosts[mac].ipv6)[0]; + o.value(mac, hint ? '%s (%s)'.format(mac, hint) : mac); }); + // Interface column (only for etherwake) if (has_ewk) { - o = s.option(form.Flag, 'broadcast', _('Send to broadcast address')); + o = s.option(widgets.DeviceSelect, 'iface', _('Interface'), _('Etherwake only')); // Network device selector widget, needs 'getNetworkDevices' acl (luci-rpc) + o.noaliases = true; + o.noinactive = true; + } - if (has_wol) - o.depends('executable', ETHERWAKE_BIN); + // Broadcast flag (only for etherwake) + if (has_ewk) { + o = s.option(form.Flag, 'broadcast', _('Broadcast'), _('Etherwake only')); + o.default = o.disabled; + } + + // Password field (only for etherwake) + if (has_ewk) { + o = s.option(form.Value, 'password', _('Password'), _('Etherwake only')); + o.datatype = 'string'; + o.placeholder = '00:22:44:66:88:aa or 192.168.1.1'; + o.datatype = 'or(macaddr,ip4addr("nomask"))'; // Accept MAC or IPv4 address format } + // When editing, set modal title to include target name + s.modaltitle = L.bind(function(section_id) { + var name = uci.get('luci-wol', section_id, 'name'); + return _('Edit target') + (name ? ': ' + name : ''); + }, this); + + // Keep reference to GridSection for button handlers + const gridSection = s; + + // Take default row actions and add "Wake" button + s.renderRowActions = L.bind(function(section_id) { + const defaultButtons = form.GridSection.prototype.renderRowActions.call(gridSection, section_id, _('Edit')); + + const wakeButton = E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, function() { + return this.handleWakeup(section_id, has_ewk, has_wol); + }) + }, _('Wake')); + + const buttonContainer = defaultButtons.querySelector('div'); + if (buttonContainer) { + buttonContainer.insertBefore(wakeButton, buttonContainer.firstChild); + } + + return defaultButtons; + }, this); + + // Output section, for wake results + s = m.section(form.NamedSection, 'output', 'wol', _('Output')); + s.anonymous = true; + s.render = L.bind(function() { + return E('div', { + 'class': 'cbi-section' + }, [ + E('h3', {}, _('Output')), + E('textarea', { + 'readonly': true, + 'rows': 10, + 'style': 'width: 100%; font-family: monospace;', + 'id': 'wol-output-text' + }, this.outputText) + ]); + }, this); + return m.render(); }, - handleWakeup: function(ev) { - var map = document.querySelector('#maincontent .cbi-map'), - data = this.formdata, - self = this; + handleWakeup(section_id, has_ewk, has_wol) { + const self = this; + const name = uci.get('luci-wol', section_id, 'name'); + const mac = uci.get('luci-wol', section_id, 'mac'); - return dom.callClassMethod(map, 'save').then(function() { - if (!data.wol.mac) - return alert(_('No target host specified!')); + // Determine which binary to use and verify availability + const defaultBin = uci.get('luci-wol', 'defaults', 'executable'); + let bin = defaultBin || (has_ewk ? ETHERWAKE_BIN : WAKEONLAN_BIN); - var bin = data.wol.executable || (data.has_ewk ? ETHERWAKE_BIN : WAKEONLAN_BIN), - args = []; + if (bin == ETHERWAKE_BIN && !has_ewk) + bin = WAKEONLAN_BIN; + else if (bin == WAKEONLAN_BIN && !has_wol) + bin = ETHERWAKE_BIN; - if (bin == ETHERWAKE_BIN) { - args.push('-D', '-i', data.wol.iface); + // Build argument list based on selected binary + const args = []; - if (data.wol.broadcast == '1') - args.push('-b'); + if (bin == ETHERWAKE_BIN) { + args.push('-D'); + const iface = uci.get('luci-wol', section_id, 'iface'); + if (iface) + args.push('-i', iface); - args.push(data.wol.mac); - } - else { - args.push(data.wol.mac); - } + const broadcast = uci.get('luci-wol', section_id, 'broadcast'); + if (broadcast == '1') + args.push('-b'); - ui.showModal(_('Waking host'), [ - E('p', { 'class': 'spinning' }, [ _('Starting WoL utility…') ]) - ]); - - return self.callExec(bin, args).then(function(res) { - ui.showModal(_('Waking host'), [ - res.stdout ? E('p', [ res.stdout ]) : '', - res.stderr ? E('pre', [ res.stderr ]) : '', - E('div', { 'class': 'right' }, [ - E('button', { - 'class': 'cbi-button cbi-button-primary', - 'click': ui.hideModal - }, [ _('Dismiss') ]) - ]) - ]); - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, [ - E('p', [ _('Waking host failed: '), err ]) - ]); - }); + const password = uci.get('luci-wol', section_id, 'password'); + if (password) + args.push('-p', password); + + args.push(mac); + } else { + args.push(mac); + } + + // Execute the wake command and handle output + this.appendOutput(`Sending wakeup to ${name} (${mac})...\n`); + + return this.callExec(bin, args).then(function(res) { + if (res.stdout) + self.appendOutput(res.stdout + '\n'); + if (res.stderr) + self.appendOutput('Error: ' + res.stderr + '\n'); + if (!res.stdout && !res.stderr) + self.appendOutput('Command completed with code ' + (res.code || 0) + '\n'); + self.appendOutput('\n'); + }).catch(function(err) { + self.appendOutput('Error: ' + err + '\n\n'); }); }, - addFooter: function() { - return E('div', { 'class': 'cbi-page-actions' }, [ - E('button', { - 'class': 'cbi-button cbi-button-save', - 'click': L.ui.createHandlerFn(this, 'handleWakeup') - }, [ _('Wake up host') ]) - ]); + appendOutput(text) { + // Append text to the output textarea and scroll to bottom + this.outputText += text; + const textarea = document.getElementById('wol-output-text'); + if (textarea) { + textarea.value = this.outputText; + textarea.scrollTop = textarea.scrollHeight; + } } }); diff --git a/applications/luci-app-wol/root/etc/config/luci-wol b/applications/luci-app-wol/root/etc/config/luci-wol new file mode 100644 index 0000000000..2c94f0166d --- /dev/null +++ b/applications/luci-app-wol/root/etc/config/luci-wol @@ -0,0 +1 @@ +config wol 'defaults' diff --git a/applications/luci-app-wol/root/usr/share/rpcd/acl.d/luci-app-wol.json b/applications/luci-app-wol/root/usr/share/rpcd/acl.d/luci-app-wol.json index b2bece606c..84895d6051 100644 --- a/applications/luci-app-wol/root/usr/share/rpcd/acl.d/luci-app-wol.json +++ b/applications/luci-app-wol/root/usr/share/rpcd/acl.d/luci-app-wol.json @@ -6,12 +6,14 @@ "luci.wol": [ "stat" ], "luci-rpc": [ "getHostHints", "getNetworkDevices" ] }, - "uci": [ "etherwake" ] + "uci": [ "luci-wol" ] }, "write": { "ubus": { - "luci.wol": [ "exec" ] - } + "luci.wol": [ "exec" ], + "uci": [ "add", "set", "delete", "order" ] + }, + "uci": [ "luci-wol" ] } } } diff --git a/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol b/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol index 9d1e26f1c2..7e410a4ad0 100644 --- a/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol +++ b/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol @@ -43,7 +43,7 @@ const methods = { result.stdout = fd.read('all'); result.stderr = ''; - result.code = 0; + result.code = fd.close(); } else { result.stdout = ''; result.stderr = 'disallowed'; -- 2.30.2